Skip to content

fix(lint)!: the object_extension publish door judges a computed field an extension adds by the build's field-formula pass, against the extended object as served - #33119

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-1oe8qz
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/project-thread-1oe8qz

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33072

Clause-②: no (narrowing) — the runtime object_extension publish door (Studio's add-field on a .custom pair, PUT /api/v1/meta/object_extension/TARGET, the MCP and AI authoring doors) now refuses an extension whose computed field carries an expression os build refuses. No exported type or signature changes. Measured first-party exposure: 0 newly refused items.
Metadata-protocol: narrowed
Skills-docs: none owed — this PR edits content/docs/deployment/validating-metadata.mdx (the CEL row's runtime column gains ᵒˣ, and the object extension door paragraph names the formula pass); skills/objectstack-data teaches defineObjectExtension({ extend, fields }) and the computed-field rules without saying the runtime door is exempt, so the door now gives the verdict the build already gives.

维护者速读

What changes

Lint: the door runs the build's one pass.

  • packages/lint/src/authoring-rules.ts. validateStackExpressions declares object_extension in runtimeTypes, so runtimeAuthoringRulesFor('object_extension') answers validateStackExpressions, validateSecurityRoleWord (registry order).
  • packages/lint/src/validate-expressions.ts. On an object_extension write (runtimeWriteType), runStackExpressionPasses runs exactly one pass, by construction: lint: a stored computed field added by an object extension is never judged by the field-formula pass (stored-expression warnings and expression errors skip it) #32875's extension walk (judgeFieldFormula over each field of each objectExtensionsByTarget entry). The object walk and the flows walk read an empty list under it, and the function returns right after the extension walk. No second field walk and no second fold: the door's snapshot is { objects: LIVE_OBJECTS, permissions, books, datasets, objectExtensions: [WRITTEN_ENTRY] }, and the walk reads the target through the indexes already built over objectsAsServed.
  • packages/lint/src/runtime-gate.ts. Comment only, on the object_extension row of TYPE_TO_STACK_KEY.

Metadata-protocol: the target is the one the write will serve.

A package holds one extension per target, so a republish replaces the stored one. The gate used to hand the walk the target as served before the write, with the stored extension still folded in. That was wrong both ways: dropping a field the extension's own formula reads was admitted, and a formula over a lookup the same write re-targets was judged over the old target.

  • packages/metadata-protocol/src/protocol-authoring-gate.ts. objectExtensionWriteFold names the narrowing for an object_extension write that names a package: the registry's existing withoutExtensionsOf fold option, on the body's extend target. withTargetFoldedWithout takes only the target's entry from listItems('object', undefined, { withoutExtensionsOf }) and keeps every other object as served, because the option is package-wide and the package's extensions of other objects are not being written. The narrowing is keyed on the singular type, so the plural retry narrows the same way.
  • protocol-stored-collection-fold.ts, protocol-object-extender-fold.ts, and the two private wrappers in protocol.ts (amendment 6858898742). There is a new optional trailing argument on each fold: targetFold on foldStoredCollection, and withoutExtensionsOf on foldObjectExtendersFromRegistry. They apply the same narrowing to a target that has its own stored object row, and only to that row. Every other caller passes nothing and reaches the call it made before. With both onlyExtensionsOf and withoutExtensionsOf given, both are handed to the registry, which leaves out a contribution either one leaves out; no caller passes both today. Both method docblocks state this.

Docs, migration and changeset.

  • content/docs/deployment/validating-metadata.mdx. The CEL row's runtime column is now ✓ᶠᵃᶜʰᵒˣᵒᵛᵖ: ᵒˣ goes before ᵒ, because after ᵖ the longest-first reading would take ᵖᵒ. One sentence was added to the object extension door paragraph.
  • packages/spec/src/migrations/entries/semantic/18.object-extension-door-computed-field-expression-judged.ts is the ADR-0087 D3 entry the narrowing owes. There is no D2 conversion, because how a refused expression is repaired is the author's fact.
  • .changeset/33072-object-extension-door-formula.md: @objectstack/lint minor, @objectstack/spec minor, @objectstack/metadata-protocol patch, both declaration lines, BREAKING banner, migration.

Known limit (#33152). A package-wide draft publish (publishPackageDrafts) whose drafts depend on a sibling extension draft is refused, because that batch's pending set carries no extension drafts. Publishing the extension first through the per-item door works. The same gap exists on main for object drafts. The changeset, the entry and the [#33072] docblocks state it.

Tests

  1. packages/lint/src/runtime-gate.object-formula-writes.test.ts, new describe #33072 (10 cases) at the real gate (runRuntimeAuthoringRules). Cases:
    • the roster;
    • LIT: sqrt(record.amount) is refused under expression-invalid at the build's location;
    • LIT: a read of a field the target does not serve is refused;
    • LIT: a stored field reading another record is an advisory, never a 422;
    • PARITY: on one shared input, the door's findings deep-equal the build's, for an error and for an advisory;
    • CONTROL: a valid formula, and a formula reading a field the same extension adds, both publish clean;
    • an undeclared target is judged on what does not depend on its fields;
    • the differential;
    • the narrowing;
    • CONTROL: the object save door is unchanged.
  2. packages/runtime/src/domains/meta-object-extension-item.test.ts, new describe [#33072] (6 cases) through the real saveMetaItem + publishMetaItem over better-sqlite3 and the real ObjectQL/SchemaRegistry. Cases:
    • LIT sqrt(record.name) → 422 expression-invalid at object 'crm_account' · objectExtensions[0] · field 'score__c' expression, the field not served and the row left a draft;
    • LIT: a misspelt field read is refused;
    • CONTROL: a valid formula publishes;
    • and three republish cases, each over a stored self:
      • lenient direction: dropping bonus__c while total__c reads it → 422 (was 200);
      • strict direction: re-targeting lookup ref__c from crm_region to crm_account with via__c: record.ref__c.name → 200 (was 422 against crm_region). This is also the first hop pinned on the real door;
      • a target with its own stored object row (the pair's runtime-authored crm_custom_lead) → 422.
  3. packages/metadata-protocol/src/protocol-authoring-gate.extension-write-fold.test.ts (new, 7 cases):
    • objectExtensionWriteFold: which writes narrow;
    • withTargetFoldedWithout: only the target is replaced;
    • foldObjectExtendersFromRegistry: what each package argument hands the registry, both included.
  4. packages/lint/src/validate-security-posture.runtime-surface.test.ts: the security-role-word lint skips fields declared through objectExtensions, so a field named role passes validate, build and verify #31998 extension case's roster and rulesRun pins now read ['validateStackExpressions', 'validateSecurityRoleWord'].

Reproduction at main 2c98485d, with the pins committed first: 8 of the 10 lint door cases were red. The door published sqrt(record.amount) with 0 errors while the build refused it.

Ablations (fix committed first, mutated through scripts/ablation-replace.mjs, each restore read blob == HEAD and git diff HEAD empty)
# Mutation Expected Observed
1 object_extension dropped from the rule's runtimeTypes, lint rebuilt, dist marker proved absent lint door pins and runtime LIT pins red, controls green lint 8 failed / 41 passed; runtime 2 failed (both LIT), CONTROL passed
2 the object-walk fence on an extension write removed the narrowing pin red 1 failed (the narrowing pin)
3 host narrowing off (extensionWrite forced undefined), metadata-protocol rebuilt the three republish pins red, the r0 pins green 3 failed / 3 passed
4 stored half off (the target-name match in foldStoredCollection broken), metadata-protocol rebuilt only the stored-object-row pin red 1 failed / 5 passed

Acceptance notes

  • Exposure, measured at 2c98485d: examples/app-showcase has one extension entry with 3 plain fields; packages/plugins/plugin-auth has one with a secret field; HotCRM (4f0ffd8, re-verified at 0009f58) has none. 0 carry an expression, so the door refuses nothing first-party.
  • Location agreement is index-exact for a package with one extension entry. The door's snapshot always holds the written entry at objectExtensions[0]. The build of a package with several entries prints that entry's own index, and packages[j].manifest.objectExtensions[i] in an artifact stack. Rule id, object, field and message are the same in every case.
  • N1. A served object_extension draft's _diagnostics now carries the formula verdict, because draftPublishVerdict reads the same roster. No prose face names it, and no pin covers it.
  • N3. The real door now pins a hop (the strict republish case). There is still no real-door pin for label() or for the draft _diagnostics. The lint "(the differential)" case passes by the object-walk fence.
  • N4. An extension of an object outside the written package's closure is refused first by the item gate (objectExtensionItemRefusal step 5), so at the door the formula pass never sees it. Only the lint API reaches the "judged on what does not depend on the target's fields" arm.
  • N5, N6. The two folds' docblocks describe their optional arguments and the both-given case. The changeset names @objectstack/metadata-protocol (patch), which carries the host half.
  • Filed by the seat, out of scope here:
  • Ruling grep (ruling|Ruling-ref|maintainer|⛔) over the surface: the readings that bear on this diff are "the per-write snapshot is not widened for cross-item judgments" (rulings 5791822697 item 3 and 6104584601) and "⛔ no context row for symmetry" (runtime-gate.ts). This diff adds no context collection and no snapshot key; it narrows the existing objects list for the target. None is reversed.
  • File surface. Production changes are in packages/lint, in packages/metadata-protocol (the gate plus the two folds' optional pass-through, amendment 6858898742), in the docs page and in the semantic entry. packages/runtime is test only.
  • Release: 18.4.0 shipped before this PR opened, so this rides the next release train.

Verification

Readings at HEAD f57cc273 unless noted. f57cc273 is the r2 prose and metadata commit on top of ac6dd79d, which merged origin/main bf02d33b cleanly. main is ahead again and merges clean; it was not merged again.

Tests

Each heavy command ran under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0). Exit codes were captured before any pipe.

  • At f57cc273, after pnpm build --concurrency=2 (exit 0):
    • @objectstack/metadata-protocol full suite: Test Files 338 passed | 7 skipped (345), Tests 22810 passed | 36 skipped;
    • @objectstack/spec full suite: Test Files 931 passed (931), Tests 28389 passed | 1 todo;
    • meta-object-extension-item.test.ts: 29 passed (29), the 6 [#33072] cases among them.
  • At ac6dd79d (the r1 head; r2 changes comments, the changeset, the entry's text, one fold's option hand-off for a case no caller reaches, and test files only):
    • pnpm check:dual-build-cjs-loads exit 0;
    • dogfood battery (owed by the (narrowing) arm): 460 files and 3874 tests passed, 42 skipped;
    • @objectstack/runtime full suite: 11938 passed, 19 skipped;
    • @objectstack/lint full suite: 8751 passed.
  • CI at ac6dd79d: 68 check runs, every one success or skipped.
Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at f57cc273 derives 119 families. Each ran with stdin from /dev/null: all 119 exited 0. The --ran reconciliation read: 119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED.


🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 13, 2026
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/metadata-protocol, @objectstack/spec, touching 16 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/data-modeling/object-extensions.mdx (via object_extension (literal, a string literal in objectExtensionWriteFold; a string literal in runStackExpressionPasses; a string literal in runtimeTypes))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES), object_extension (literal, a string literal in objectExtensionWriteFold; a string literal in runStackExpressionPasses; a string literal in runtimeTypes))
  • content/docs/kernel/cluster.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), object_extension (literal, a string literal in objectExtensionWriteFold; a string literal in runStackExpressionPasses; a string literal in runtimeTypes))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v18/18-3.mdx (via object_extension (literal, a string literal in objectExtensionWriteFold; a string literal in runStackExpressionPasses; a string literal in runtimeTypes))
  • content/docs/releases/v18/18-4.mdx (via object_extension (literal, a string literal in objectExtensionWriteFold; a string literal in runStackExpressionPasses; a string literal in runtimeTypes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f5121e84d56d4e92a4cc40171c65dab84d801b89 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8889d9021bc5f416c9d64f231d0c51f49019bd6e — the merge of head bb8a46225eb8eef07f93863a1adba76b3976e0e1 into base f5121e84d56d4e92a4cc40171c65dab84d801b89, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8889d9021bc5f416c9d64f231d0c51f49019bd6e && git checkout 8889d9021bc5f416c9d64f231d0c51f49019bd6e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f5121e84d56d4e92a4cc40171c65dab84d801b89 bb8a46225eb8eef07f93863a1adba76b3976e0e1 && git checkout -B drift-repro f5121e84d56d4e92a4cc40171c65dab84d801b89 && git merge --no-ff bb8a46225eb8eef07f93863a1adba76b3976e0e1

node scripts/docs-audit/affected-docs.mjs --json f5121e84d56d4e92a4cc40171c65dab84d801b89

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f5121e84d56d4e92a4cc40171c65dab84d801b89 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8c1950cb90bf35f15139272d93aed5157d9e813e
Local-runs: probe — what the host hands the gate on an update of a stored extension is not readable from the lint diff; head tree exported with git archive under the scratchpad, checkout untouched
Scope: packages/lint/src/authoring-rules.ts, packages/lint/src/validate-expressions.ts, packages/lint/src/runtime-gate.ts (comment only), packages/spec/src/migrations/entries/semantic/18.object-extension-door-computed-field-expression-judged.ts, packages/lint/src/runtime-gate.object-formula-writes.test.ts, packages/lint/src/validate-security-posture.runtime-surface.test.ts, packages/runtime/src/domains/meta-object-extension-item.test.ts, .changeset/33072-object-extension-door-formula.md (declaration lines and prose) + read at head: packages/lint/src/object-graph.ts, packages/metadata-protocol/src/{protocol-authoring-gate.ts,runtime-authoring-gate.ts,protocol-object-extension-item.ts,protocol-meta-item-reads.ts,metadata-diagnostics.ts}, packages/objectql/src/registry.ts (listItems, ObjectExtensionFoldOptions), packages/spec/src/migrations/types.ts, content/docs/deployment/validating-metadata.mdx

Check-runs at review time (2026-11-13T16:27Z): 67 runs, 56 success, 11 skipped, 0 failed, 0 pending. mergeable_state clean. main moved 9433d4f3 → 5c005e0e with no change to any path of this diff.

Local runs at head, in the scratch tree: @objectstack/lint 202 files / 8625 tests pass; the PR body's -t "#33072" command 10 pass; meta-object-extension-item.test.ts 26 pass; five metadata-protocol gate and extension files 296 pass; packages/spec/src/migrations/ 198 pass. Dropping object_extension from runtimeTypes in the scratch copy turns 8 of 49 red (the dev's ablation 1); restored byte-identical. A reviewer probe of 11 cases drove the real saveMetaItem + publishMetaItem over better-sqlite3 and the real registry; ① cites it.

① Derived judgments

  • Roster: runtimeAuthoringRulesFor('object_extension') answers validateStackExpressions, validateSecurityRoleWord. The early return sits inside runStackExpressionPasses, so the vocabulary rule still runs (probe: role__c → 422 security-role-word). Right.
  • One pass: on that write the flow loop and the object walk read [], the lint: a stored computed field added by an object extension is never judged by the field-formula pass (stored-expression warnings and expression errors skip it) #32875 extension walk runs, and return issues (validate-expressions.ts:4043) precedes the remaining passes. Three fences and one return: no second walk, no second fold. Right.
  • Other writes: extensionWrite is false for every other write type and for the CLI; the three edited expressions then reduce to the merge-base text. Right.
  • Snapshot: { objects, permissions, books, datasets, objectExtensions: [item] }; objects is registry.listItems('object') plus stored object rows, narrowed to the written package's closure. The item gate refuses an unregistered or out-of-closure target by the same predicate, so a registry-backed door always carries the target with its declared fields. Right.
  • Door equals build on one shared input: 8 bodies through the real door give the same verdict, rule id, where, path and message as runAuthoringRules('build', …). Each refusal is 422 INVALID_METADATA, expression-invalid at object 'crm_account' · objectExtensions[0] · field 'score__c' expression. Right.
  • Served, not declared: a field another package's extension adds resolves at the door, and a field the write redefines is judged on the written definition. Right.
  • Update of a stored extension: the door judges against the target as served before the write. Wrong on two shapes, S1.
  • Differential: the baseline holds no objectExtensions, so every finding on the written entry is the write's. A field stored bad before this gate blocks every later publish of the same per-target row that re-sends it; this is the gate's rule for a finding on the written item (finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118), and the changeset's "judged only when it is published again" covers it. Right.
  • No export moves. Right.

② Semver level

Clause-②: no (narrowing) + Metadata-protocol: narrowed is correct: a runtime door newly refuses, and no published schema or exported signature moves. lint minor + spec minor with the BREAKING banner matches #33070. The entry has the five SemanticMigration fields, and the adr-0087: registered id equals the entry id. Every changeset bullet holds except where S1 limits "refuses an extension whose computed field carries an expression os build refuses". Skills-docs: none does not hold (S2).

③ Boundary flags

  • open_questions: empty.
  • The claim's bare no versus no (narrowing): the arm is correct and is what narrowed requires.
  • File surface: packages/runtime/src/domains/meta-object-extension-item.test.ts is outside the listed surface. Acceptable: test only (+42 lines), the publish door's own file, the end-to-end pin the order asks for, declared in the dev report. Production change is confined to packages/lint plus the semantic entry.
  • Exposure, verified: examples/app-showcase one entry with three plain fields; packages/plugins/plugin-auth one entry with one secret field; packages/apps/** none; HotCRM 0009f58 none. 0 newly refused.
  • Dev out-of-scope, confirmed and not filed: an extension's validations are judged at neither door, yet the registry registers them on the object. The seat decides on a card.
  • PR author objectstack-fleet[bot].

④ Design judgment

Reusing the build's walk behind a write-type fence is the right shape: one judge, the registry entry declares the type, the gate owns no judgment. What is not yet right is the universe: an extension's stored self lives folded inside objects, so the candidate is not the object as served after the write (S1).

No must-fix. Two should-fix:

  • S1 · the door judges an update against the target as served BEFORE the write. registry.listItems('object') already folds the written package's stored extension of the target; objectsAsServed overlays the written entry by field name and cannot take out what the write drops, and refuseUndeclaredHopField reads the live object. Two measured disagreements with the build on one body:

    • Lenient: publish { bonus__c: number, total__c: formula 'record.amount + record.bonus__c' }, then publish the entry without bonus__c. The door answers 200 and the registry then serves total__c with no bonus__c. The build refuses that body.
    • Strict: publish { ref__c: lookup → crm_region }, then { ref__c: lookup → crm_account, via__c: formula 'record.ref__c.name' }. The door answers 422 unknown field name on crm_region. The build accepts the body.

    Rework: hand the gate the target without the written package's own stored extension of it (the registry already answers a fold without one package's contributions, withoutExtensionsOf), with a pin per direction on the real door. If deferred, the changeset, the entry and the [#33072] docblocks must state the limit.

  • S2 · Skills-docs: none is not true of the docs. content/docs/deployment/validating-metadata.mdx:497 gives the CEL row's runtime column as ✓ᶠᵃᶜʰᵒᵛᵖ, where each superscript is a write type the rule inspects; at head the rule also inspects object_extension (ᵒˣ), and lines 601–609 describe that door as running the vocabulary rule only. Rework: add ᵒˣ and a sentence.

Nits:

  • N1 · a served object_extension draft's _diagnostics now carries the verdict (draftPublishVerdict reads the same roster). No prose face names it.
  • N2 · location parity is index-exact only for a one-entry stack; the changeset's "located where the build locates it" and the entry's reason do not say so. The entry's acceptanceCriteria alternative "or run os validate on the package that ships it" is weaker than the door for a pair package validated alone.
  • N3 · tests: no case publishes over a stored self (S1), none pins label() or a hop on the real door, none pins the draft _diagnostics. The "(the differential)" case passes by the object-walk fence.
  • N4 · PR body: the list of incoming main commits omits three paths (none in this diff); "an extension of an object outside the written package's closure …" describes an arm the item gate refuses first.

S1 and S2 are owed before landing; this reviewer delta-reviews the fix head.

Implemented-by: claude/project-thread-1oe8qz
Reviewed-by: subagent of session_01L9qibvfX5bawZKtfa6u7j9

VERDICT: PASS

…he package's stored extension

The runtime authoring gate gathered the extension's target as served
before the write, so the package's stored extension (which the write
replaces) still lent its fields to the judgment. Dropping a field the
extension's own formula reads was admitted, and re-targeting the
extension's own lookup was judged over the old target.

The gate now takes the target from the registry fold that leaves the
written package's `extend` contributions out (`withoutExtensionsOf`,
applied to the target by name only), and passes the same narrowing
through the stored-row fold for a target with its own stored object row.
Every other caller of both folds reaches the call it made before.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
…tadata, and its location parity stated exactly

The CEL row's runtime column gains the object_extension marker and the
door paragraph names the formula pass. The changeset and semantic entry
say that location parity with the build is index-exact for a package
with one extension entry, state the republish semantics, and drop the
weaker `os validate` acceptance alternative.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
…ular type

The registry list narrowed the target by comparing the listed type with
both spellings, which brought back an executable `'objects'` literal the
#8862 census forbids. The narrowing is now decided once from the
collection's singular type, so the plural retry narrows the same way.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
…sion-write fold pins

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
@github-actions github-actions Bot added size/l and removed size/m labels Nov 13, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ac6dd79dc321ad0b6d8c2bd4f3f87ebf96803ae7
Local-runs: probe — the two S1 shapes and the reach of the fold option are verdicts of the real door over a stored self, not readable from the diff; fix head exported with git archive under the scratchpad, checkout untouched
Scope: delta review of record 6858831273 (head 8c1950cb) and amendments 6858847138 / 6858898742. Delta files: packages/metadata-protocol/src/protocol-authoring-gate.ts, protocol-stored-collection-fold.ts, protocol-object-extender-fold.ts, protocol.ts (the two private wrappers), protocol-authoring-gate.extension-write-fold.test.ts, packages/runtime/src/domains/meta-object-extension-item.test.ts, packages/spec/src/migrations/entries/semantic/18.object-extension-door-computed-field-expression-judged.ts, .changeset/33072-object-extension-door-formula.md, content/docs/deployment/validating-metadata.mdx + what the 14 merged main commits changed on the PR's paths (packages/lint/src/validate-expressions.ts, authoring-rules.ts, packages/metadata-protocol/src/protocol.ts, and the new packages/lint/src/formula-cycle-issues.ts) + read at head: packages/objectql/src/registry.ts (getAllObjects, resolveObjectLeavingExtensionsOut, registerObjectExtension), packages/metadata-protocol/src/{runtime-authoring-gate.ts,protocol-publish-batch.ts}, every other caller of the two folds

Check-runs at review time (2026-11-13T21:03Z): 68 runs, 58 success, 10 skipped, 0 failed, 0 pending. The PR is a draft, mergeable_state clean. main moved bf02d33b → 702c562c; the only PR path it touches is authoring-rules.ts (userTextParams pass-throughs on other entries).

Local runs at head, in the scratch tree: @objectstack/lint 205 files / 8751 tests pass; meta-object-extension-item.test.ts 29 pass; nine metadata-protocol files (the new helper file among them) 331 pass; packages/spec/src/migrations/ 198 pass. Both of the dev's ablations reproduce: with the host narrowing off the three republish pins go red, with the stored half off only the stored-row pin does; both restored byte-identical, 6 of 6 green again. A reviewer probe of 18 cases drove the real save, publish and batch-publish doors at BOTH heads (8c1950cb and ac6dd79d); ① cites it.

① Derived judgments

  • S1, lenient direction: publish { bonus__c: number, total__c: formula 'record.amount + record.bonus__c' }, then publish the entry without bonus__c. The door now answers 422 expression-invalid, unknown field bonus__c on crm_account, at object 'crm_account' · objectExtensions[0] · field 'total__c' expression, deep-equal to the build's finding on that body, and the registry stays as published. It answered 200 at 8c1950cb. Right.
  • S1, strict direction: publish { ref__c: lookup → crm_region }, then { ref__c: lookup → crm_account, via__c: formula 'record.ref__c.name' }. The door now answers 200 and the build has 0 findings. It answered 422 at 8c1950cb. The same shape on a target that has its own stored object row also moved 422 → 200. Right.
  • Everything else the probe ran gives the same answer at both heads: the 8 shared inputs still equal the build; a legacy bad field re-sent still blocks; role__c is still refused; a field the write redefines is judged on the written definition. The draft read's _diagnostics follows the narrowed verdict (the draft without bonus__c reads valid: false). Right.
  • Reach, callers: foldStoredCollection has one caller, the gate, which states targetFold only for the object collection on an object_extension write. The five other callers of foldObjectExtendersFromRegistry (item, layered and list reads) pass at most four arguments and the PR does not touch them. objectExtensionWriteFold answers undefined for every other type and for a write naming no package. Right.
  • Reach, by name: the registry option is package-wide (isLeftOutExtension matches the package on every object), and withTargetFoldedWithout takes only the target from the narrowed list. Probe: the pair's own tier__c on crm_region stays visible to its crm_account extension on a first publish and on a republish (record.region.tier__c 200, record.region.nope__c 422), and another package's mod_tier on the target stays visible on a republish, in both directions. Right.
  • Registry semantics: a package holds one extend contributor per object (registerObjectExtension replaces it), so leaving the package out of the target's fold removes exactly the stored self. An object the package does not extend still reaches the cached resolveObject, so the second list costs a re-fold of the objects that package extends and nothing more. Right.
  • Other writes: an object publish of a target the pair also extends gives the same verdict at both heads. Right.
  • No published surface moves: neither helper nor StoredCollectionTargetFold is on the package entry, and the two class methods are private. Right.
  • S2: row 497 reads ✓ᶠᵃᶜʰᵒˣᵒᵛᵖ, which the legend's longest-first rule reads as flow, action, hook, object_extension, object, view, page (after ᵖ it would have read as position). The sentence at 609–612 matches the code, and the changeset line names the docs edit. Holds in the tree.
  • N2: the changeset and the entry state index parity for a package with one entry, reason names the republish semantics, and acceptanceCriteria no longer offers os validate. Right.

② Semver level

Unchanged and still correct: Clause-②: no (narrowing) + Metadata-protocol: narrowed, lint minor + spec minor with the BREAKING banner. The new republish bullet holds against the probe. The entry's replacement ("an expression os build accepts") now holds on the single-item door; S3 is the one door where it does not.

③ Boundary flags

  • Surface: the delta stays inside amendments 6858847138 / 6858898742 (four metadata-protocol files, the docs page, the runtime test) plus the new helper test. No packages/lint production line moved in the delta.
  • PR body: still the first head's text. False at this head: Skills-docs: none — …, "The host side needed no change", "No production file outside packages/lint changes", "All readings at HEAD 8c1950cb", and a "What changes" list with no metadata-protocol or docs file. The seat's paste of the acceptance notes has to cover all five before landing.
  • From the merge, validate-expressions.ts: fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108 put the formula-cycle member inside the object walk, which this PR fences on an extension write. The member reports declared field names only and the extension walk has no cycle member. Probe: a formula an extension adds that reads itself, or two that read each other, publishes at the door and has 0 findings at the build, while the same formula on a declared object is refused at both. The two doors agree, so this PR's claim holds; the gap belongs to fix(objectql)!: a formula reading another formula reads its computed value; a cycle of formulas is refused where it is authored #33108 × lint: a stored computed field added by an object extension is never judged by the field-formula pass (stored-expression warnings and expression errors skip it) #32875. A cycle closed through a declared formula is named by the build at the declared member and not by the extension door. The seat decides on a card.
  • From the merge, the rest: authoring-rules.ts gains userTextParams pass-throughs on two other entries; protocol.ts gains readMetaItemFace host members and a docblock; three new semantic entries, no id clash. main changed nothing in the overlay merge, the registry, object-graph.ts, runtime-gate.ts, the item gate or the save and publish doors.
  • Seen by the object-write control, same at both heads, not this diff, one reproduction each: an object's own formula cannot read a field its package's published extension adds (unknown field at the object door, while the build accepts), and the object save's destructive-change guard reads such a field as removed unless forced.
  • Carried: an extension's validations are judged at neither door (the seat decides on a card).
  • PR author objectstack-fleet[bot].

④ Design judgment

The fix is the right shape. It composes two answers of the registry's own fold by name, adds no fold of its own, and makes the candidate the target as it will be served after the write. Passing the option through the stored fold is needed, as the stored-row ablation shows.

No must-fix. S1 is closed. S2 is closed in the tree; its PR-body line waits on the seat. One should-fix, new at this head's review:

  • S3 · the batch publish door refuses a package whose extension drafts depend on each other. Outside the delta and the same at both heads; my first record read that the batch's pending set carries no extension drafts and did not probe it. Shape: the pair drafts { extend: 'crm_region', fields: { tier__c } } and { extend: 'crm_account', fields: { score__c: formula 'record.region.tier__c' } }, then publishPackageDrafts. The answer is INVALID_METADATA, unknown field tier__c on crm_region, and the all-or-nothing batch rolls back both. The build accepts both entries (0 findings, with or without the base in the stack), and publishing the region extension first through the per-item door then accepts the other. Before this PR the extension door ran no formula judgment, so nothing refused that batch (derived, not run on a pre-PR tree). The object door has the same blind spot on main (an object draft hopping into a field a sibling extension draft adds is refused the same way), so the gap is the batch's pending set, which publishPackageDrafts validation closure omits same-batch dataset drafts: a package shipping dashboard+dataset together can never publish (widget-dataset-unknown) #10377 built so a package is judged as one unit. Rework, either: thread the batch's extension drafts into the gate's universe, or state the limit in the changeset and the entry and file a card for the pending set covering both doors. The second is prose only.

Nits:

  • N1, N4 · carried as the dev's notes state them.
  • N3 · partly closed: a hop is now pinned on the real door. label() and the draft _diagnostics are still unpinned, and no real-door case pins "the package's extension of another object stays served" (a unit pin over hand-built arrays only; probed here, it holds).
  • N5 · the class docblocks of foldStoredCollection and foldObjectExtendersFromRegistry, which the moved bodies name as their contract, do not describe the new optional parameters. foldObjectExtendersFromRegistry drops onlyExtensionsOf when both are given, though the registry accepts both; no caller passes both.
  • N6 · the changeset frontmatter names lint and spec only, while @objectstack/metadata-protocol now carries the host half. The fixed group moves its version anyway; its changelog will not carry the line.
  • N7 · Skills-docs: none owed — … is not one of the four listed spellings; updated here is the listed one for a docs edit in the PR. The seat's amendment allowed it.

S3 is owed before landing. If it is resolved by prose and a card, this record continues under the prose-only head-move rule; a code fix owes a delta review.

Implemented-by: claude/project-thread-1oe8qz
Reviewed-by: subagent of session_01L9qibvfX5bawZKtfa6u7j9

VERDICT: PASS

… optional package arguments

The changeset, the semantic entry and the docblocks now say that a
package-wide draft publish whose drafts depend on a sibling extension
draft is refused (#33152), and that publishing the extension first
through the per-item door works. The changeset names the
metadata-protocol package, which carries the host half.

The two folds' docblocks describe their optional arguments, and the
extender fold hands both package options to the registry when both are
given rather than dropping one; no caller passes both today.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
…-1oe8qz

# Conflicts:
#	packages/lint/src/validate-expressions.ts
…r id

The printed migration guidance of a semantic entry carries no tracker
number. The batch-publish sentence keeps its substance; the changeset
and the docblocks still cite the follow-up card.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwwiSN1TXWjohzCZN9
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-tesla · session_01L9qibvfX5bawZKtfa6u7j9

维护者速读

席位意见:可以落地。

席位核对

ACCEPT · seat domain:spec#2 · head bb8a4622 · 2026-11-14T00:06Z

  1. Shape: Fixes #33072, draft, base main, 14 files, +588/−28. Changeset .changeset/33072-object-extension-door-formula.md: @objectstack/lint minor, @objectstack/spec minor, @objectstack/metadata-protocol patch, with a BREAKING banner. Semantic entry object-extension-door-computed-field-expression-judged. Clause-②: no (narrowing), Metadata-protocol: narrowed. These match claim 6855427836, amendments 6858847138 and 6858898742, and dev report 6858450997.
  2. Premise: lint: a stored computed field added by an object extension is never judged by the field-formula pass (stored-expression warnings and expression errors skip it) #32875 gave the build its extension walk. The runtime object_extension door ran no field-formula judgment, so an extension os build refuses could be published from Studio. Exposure: repo examples and HotCRM 0009f58 newly refused 0 (review ③).
  3. Review: contract review at tier, through an isolated read-only subagent.
    • Record 6858831273 at 8c1950cb: PASS, with S1 (a republish was judged against the pre-write target) and S2 (docs runtime column).
    • Delta record 6864935727 at ac6dd79d: PASS.
    • r2 f57cc273, seat-checked: N5 docblocks; N6 metadata-protocol changeset line; the both-given fold call made explicit, equivalent for every single-argument shape, with 2 new pins; PR body rewritten for the current head.
  4. Gates / check-runs:
    • Dev at 18771ae0, after merging main (conflict: one hunk in validate-expressions.ts against fix(mcp): validate_expression gives the publish gate's verdict at validation and formula sites #33134's validatorRunByCaller, both kept, independent): full build exit 0, lint 8835, spec migrations 198, extension door file 29, 119/119 derived gates exit 0.
    • bb8a4622 removes the tracker id from the entry's acceptanceCriteria, after 18771ae0 turned Test Core red at packages/cli/test/migrate-meta-engine-guidance.test.ts (semantic-entry guidance carries no tracker id). That test passes locally, and ablating the id back turns it red again.
    • CI on bb8a4622: 68 runs, 58 success, 10 skipped, 0 failed. K = 7; git merge-tree against main 588596af is clean.
    • Not governed (check-governed-merges --pr 33119: 0 of 14 paths), 616 changed lines.
  5. Accepted deviations and out-of-scope findings:
  6. Landing: the seat readies the PR and arms auto-merge under the maintainer's standing words 「车道经理应该使用 pm-dispatch 技能审核和落地pr」. On MERGED: lint: the object_extension publish door runs no field-formula judgment, so a computed field os build refuses still publishes through PUT /meta/object_extension #33072 closes via Fixes; pm:dispatched comes off.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 00:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 3e19223 Nov 14, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-1oe8qz branch November 14, 2026 00:31
os-elon-musk pushed a commit that referenced this pull request Nov 14, 2026
…-loearv

Second and last merge of main for #32607, at the seat's landing request (the head was 25 commits behind). Clean: protocol.ts auto-merged (#33119's hunks are elsewhere in the file).

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czBmkW6UeWPazkWq7L
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants